← Back to Learning Journey

Mastering Async/Await: Beyond the Basics

Deep dive into async/await patterns, error handling, and advanced JavaScript concurrency patterns

The Async/Await Journey

Coming back to JavaScript after a hiatus, async/await felt familiar but I quickly realized there's so much more to it than just replacing .then() chains. Today I'm diving deep into patterns that make asynchronous code more robust and efficient.

Why Async/Await Matters

Async/await makes asynchronous code look synchronous, but understanding the patterns underneath is crucial for writing maintainable applications.

Basic Syntax

// Old way with promises
fetch("/api/users")
  .then(response => response.json())
  .then(users => console.log(users))
  .catch(error => console.error(error))

// New way with async/await
async function getUsers() {
  try {
    const response = await fetch("/api/users")
    const users = await response.json()
    console.log(users)
  } catch (error) {
    console.error(error)
  }
}

Essential Patterns

1. Sequential Execution

When operations depend on each other:

async function createUserProfile(userId) {
  const user = await fetchUser(userId)
  const profile = await createProfile(user)
  const settings = await initializeSettings(profile.id)
  return settings
}

Use case: Each step needs the result from the previous one.

2. Parallel Execution

When operations are independent:

async function loadDashboard(userId) {
  // All these run in parallel - much faster!
  const [user, posts, notifications] = await Promise.all([
    fetchUser(userId),
    fetchPosts(userId),
    fetchNotifications(userId),
  ])

  return { user, posts, notifications }
}

Performance tip: Use Promise.all() for independent operations. It fails fast if any promise rejects.

3. AllSettled Pattern

When you want all results regardless of failures:

async function loadUserData(userId) {
  const results = await Promise.allSettled([
    fetchUser(userId),
    fetchPosts(userId),
    fetchSettings(userId), // This might fail, but others continue
    fetchPreferences(userId),
  ])

  const user = results[0].status === "fulfilled" ? results[0].value : null
  const posts = results[1].status === "fulfilled" ? results[1].value : []
  // Handle each result individually
}

Use case: Partial failures shouldn't break the entire operation.

Error Handling Strategies

Pattern 1: Try-Catch with Specific Errors

async function processPayment(orderId) {
  try {
    const order = await fetchOrder(orderId)
    const payment = await processPayment(order)
    await sendConfirmationEmail(payment)
    return payment
  } catch (error) {
    if (error instanceof PaymentError) {
      // Handle payment-specific errors
      await logPaymentFailure(orderId, error)
    } else if (error instanceof NetworkError) {
      // Handle network errors differently
      await retryPayment(orderId)
    } else {
      // Unexpected errors
      throw error
    }
  }
}

Pattern 2: Result Wrapper

Instead of throwing errors, return a result object:

async function safeFetch(url) {
  try {
    const response = await fetch(url)
    const data = await response.json()
    return { success: true, data }
  } catch (error) {
    return { success: false, error: error.message }
  }
}

// Usage
const result = await safeFetch("/api/users")
if (result.success) {
  console.log(result.data)
} else {
  console.error(result.error)
}

Pattern 3: Error Boundaries

async function fetchWithRetry(url, retries = 3) {
  for (let i = 0; i < retries; i++) {
    try {
      const response = await fetch(url)
      if (!response.ok) throw new Error(`HTTP ${response.status}`)
      return await response.json()
    } catch (error) {
      if (i === retries - 1) throw error
      await new Promise(resolve => setTimeout(resolve, 1000 * (i + 1)))
    }
  }
}

Advanced Patterns

Concurrent Processing with Limits

Process items in batches to avoid overwhelming the server:

async function processItems(items, limit = 3) {
  const results = []

  for (let i = 0; i < items.length; i += limit) {
    const batch = items.slice(i, i + limit)
    const batchResults = await Promise.all(batch.map(item => processItem(item)))
    results.push(...batchResults)
  }

  return results
}

Rate Limiting

Control how fast you make requests:

class RateLimiter {
  constructor(requestsPerSecond) {
    this.requestsPerSecond = requestsPerSecond
    this.lastRequestTime = 0
    this.minInterval = 1000 / requestsPerSecond
  }

  async wait() {
    const now = Date.now()
    const timeSinceLastRequest = now - this.lastRequestTime

    if (timeSinceLastRequest < this.minInterval) {
      await new Promise(resolve =>
        setTimeout(resolve, this.minInterval - timeSinceLastRequest)
      )
    }

    this.lastRequestTime = Date.now()
  }
}

// Usage
const limiter = new RateLimiter(5) // 5 requests per second

async function fetchWithRateLimit(url) {
  await limiter.wait()
  return fetch(url)
}

Timeout Pattern

Add timeouts to prevent hanging operations:

async function fetchWithTimeout(url, timeoutMs = 5000) {
  const controller = new AbortController()
  const timeoutId = setTimeout(() => controller.abort(), timeoutMs)

  try {
    const response = await fetch(url, { signal: controller.signal })
    clearTimeout(timeoutId)
    return response
  } catch (error) {
    clearTimeout(timeoutId)
    if (error.name === "AbortError") {
      throw new Error("Request timeout")
    }
    throw error
  }
}

Real-World Example: Data Pipeline

Here's a complete example combining multiple patterns:

async function processUserUpload(userId, fileIds) {
  const limiter = new RateLimiter(10) // 10 requests/second
  const results = []

  // Process files in batches of 5
  for (let i = 0; i < fileIds.length; i += 5) {
    const batch = fileIds.slice(i, i + 5)

    const batchResults = await Promise.allSettled(
      batch.map(async fileId => {
        await limiter.wait()

        try {
          const file = await fetchWithTimeout(`/api/files/${fileId}`, 3000)
          const processed = await processFile(file, userId)
          await saveProcessedFile(processed)
          return { fileId, success: true, processed }
        } catch (error) {
          await logError(fileId, error)
          return { fileId, success: false, error: error.message }
        }
      })
    )

    results.push(...batchResults)
  }

  // Summary
  const successful = results.filter(
    r => r.status === "fulfilled" && r.value.success
  ).length

  console.log(`Processed ${successful}/${fileIds.length} files`)
  return results
}

Common Pitfalls and Solutions

Pitfall 1: Accidental Sequential Execution

// Slow - runs sequentially
async function slow() {
  const user = await fetchUser()
  const posts = await fetchPosts()
  const comments = await fetchComments()
}

// Fast - runs in parallel
async function fast() {
  const [user, posts, comments] = await Promise.all([
    fetchUser(),
    fetchPosts(),
    fetchComments(),
  ])
}

Pitfall 2: Forgetting to Await

// Returns a Promise, not the actual value
function getData() {
  return fetch("/api/data").then(res => res.json())
}

//  Correct - use async/await
async function getData() {
  const response = await fetch("/api/data")
  return response.json()
}

Pitfall 3: Unhandled Rejections in Loops

//  If any fails, entire operation stops
async function processItems(items) {
  for (const item of items) {
    await processItem(item) // Stops on first error
  }
}

// Handle errors per item
async function processItems(items) {
  for (const item of items) {
    try {
      await processItem(item)
    } catch (error) {
      console.error(`Failed to process ${item.id}:`, error)
      // Continue with next item
    }
  }
}

Best Practices

  1. Always handle errors: Use try-catch or result wrappers
  2. Use Promise.all for parallel operations: Don't await sequentially when you don't need to
  3. Add timeouts: Prevent hanging operations
  4. Consider rate limiting: Don't overwhelm external APIs
  5. Use Promise.allSettled: When partial failures are acceptable
  6. Document async functions: Make it clear what they return

What I Learned

Async/await isn't just syntactic sugar. Understanding these patterns has helped me:

  • Write more efficient code (parallel execution)
  • Handle errors more gracefully
  • Build more robust systems (retries, timeouts, rate limiting)
  • Debug async code more effectively

The key insight: async/await makes code readable, but understanding concurrency patterns makes it performant and reliable.

Next Steps

I want to explore:

  • Web Workers for CPU-intensive tasks
  • Async iterators and generators
  • RxJS for reactive programming patterns
  • Server-sent events and WebSockets

The async journey continues! 🚀


Key Takeaways:

  • Use Promise.all() for parallel independent operations
  • Use Promise.allSettled() when partial failures are OK
  • Always handle errors appropriately
  • Add timeouts and rate limiting where needed
  • Profile your code to avoid premature optimization

💬 Found this helpful? Let's connect and discuss!

© 2026, Built with ❤️ &Gatsby